/**
 * This file is Copyright (C)2009 by <a href="http://www.blackbit.net">Blackbit Consulting bvba</a>, Belgium.
 * This source code is released under the GPL License, and may be freely distributed. 
 */
package net.blackbit.util.json;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * Reader for reading objects and arrays from a JSON compliant character stream.
 * 
 * @author Daan Kets
 */
public class JSONReader extends BufferedReader {
	char current=(char)-1; // We haven't read anything yet.
	int offset = -1;
	
	/**
	 * Construct a new JSON reader, that reads characters from the specified reader.
	 * @param reader The reader to read from.
	 */
	public JSONReader(Reader reader) {
		super(reader);
	}
	
	/**
	 * Construct a new JSON reader, that reads characters from the specified input stream.
	 * @param reader The input stream to read from.
	 */
	public JSONReader(InputStream inputStream) {
		super(new InputStreamReader(inputStream));
	}
	
	/**
	 * Reads an object from the JSON input. This will typically return either an array, or a map.
	 * @return The read object. Returns null of the end of the stream is encountered before the entire
	 * object was read.
	 * @throws IOException If an IO problem occurs.
	 */
	public synchronized Object readJSONObject() throws IOException {
		if (current == (char)-1){ // Read first character.
			current = next();
		}
		return readJSON();
	}
	
	/**
	 * Reads a JSON Object, array or value.
	 * @return The JSON Object read.
	 * @throws IOException If an IO exception occurs.
	 */
	private Object readJSON() throws IOException {
		skipWhiteSpace(); // Skip all white space, and read up to the first character?
		if (isStartObject(current)){
			 return readObject();
		} else if (isArrayStart(current)){
			return readArray();
		} else {
			return readValue();
		}
	}
	
	/**
	 * Read a JSON Object from the input.
	 * @return A Map<String, Object> containing the key-value pairs.
	 * @throws IOException if an IO exception occurs.
	 */
	private Map<String, Object> readObject() throws IOException {		
		Map<String,Object> map = new LinkedHashMap<String, Object>();
		skipWhiteSpace();
		findChar(new StringMatcher("{"));
		// Until end of object or stream.
		for (; !(isEndOfStream(current) || isObjectEnd(current));skipWhiteSpace()){			
			Map.Entry<String, Object> pair = readPair();
			map.put(pair.getKey(),pair.getValue());
			skipWhiteSpace();
			if (current == ','){ // Skip comma.
				current = next();
			}
		}
		next();
		return map;
		
	}
	
	/**
	 * Reads an array from the input.
	 * @return A List<Object> containing all values in order.
	 * @throws IOException If an IO exception occurs. 
	 */
	private List<Object> readArray() throws IOException {		
		List<Object> array = new LinkedList<Object>();
		skipWhiteSpace();
		findChar(new StringMatcher("["));
		for (; !(isEndOfStream(current) || isArrayEnd(current));skipWhiteSpace()){
			Object value =readJSON();
			array.add(value);
			skipWhiteSpace();
			if (current == ','){ // Skip comma.
				current = next();
			}
		}		
		next();
		return array;
	}
	
	/**
	 * Reads a pair from the input.
	 * @return A Map.Entry<String, Object> containing the entry name and value.
	 * @throws IOException If an IO exception occurs.
	 */
	private Map.Entry<String, Object> readPair() throws IOException {
		String name = readString();
		skipWhiteSpace();
		findChar(new StringMatcher(":"));
		Object value = readJSON();
		return new JSONReaderMapEntry(name,value);
	}
	
	/**
	 * Map.Entry implementation for holding the read name-value pair.
	 * @author Daan Kets
	 */
	private static class JSONReaderMapEntry implements Map.Entry<String, Object>{
		private String key;
		private Object value;
		
		public JSONReaderMapEntry(String name, Object value) {
			this.key = name;
			this.value = value;			
		}
		
		public String getKey() {
			return key;
		}		
		
		public Object getValue() {
			return value;
		}
		
		public Object setValue(Object value) {
			Object old = this.value;
			this.value = value;
			return old;
		}
	}
	
	/**
	 * Reads a value (String, Boolean Number or null) from the input.
	 * @return The value that was read.
	 * @throws IOException If an IO exception occurs.
	 */
	private Object readValue() throws IOException {
		if (isStringStart(current)){
			return readString();
		} else if (isBooleanStart(current)){
			return readBoolean();
		} else if (isNullStart(current)){
			return readNull();
		} else if (isNumberStart(current)){
			return readNumber();
		} else {
			throw new JSONParseException(offset);
		}
	}
	
	/**
	 * This method will read a JSON stream from the input. It will read from the given start character (look-ahead)
	 * up till the second quote. If the first non-whitespace character found is not a double quote, an exception is
	 * thrown.
	 * @param start The start character.
	 * @return The read string.
	 * @exception IOException If an IO exception occurs.
	 */
	private String readString() throws IOException {
		skipWhiteSpace();
		if (!isStringStart(current)){
			throw new JSONParseException();
		}
		next();
		return unescapeChars(new CharFinder(){
			public boolean matches(char character) {
				return isStringEnd(character);
			}
		});
	}
	
	/**
	 * Reads a number from the input. The number is automatically converted to the most suitable type.
	 * @return The read number.
	 * @throws IOException If an IO exception occurs.
	 */
	private Number readNumber() throws IOException {
		skipWhiteSpace();
		if (!isNumberStart(current)){
			throw new JSONParseException();
		}
		String number = findChars(new CharFinder(){
			public boolean matches(char character) {
				return "-+0123456789. eE".indexOf(character)==-1;
			}
		});
		
		if (number.contains(".")){
			Double value = Double.parseDouble(number);
			if (value>=Float.MIN_VALUE && value <=Float.MAX_VALUE){
				return value.floatValue();
			} else {
				return value;
			}
		} else {
			// Assume long
			Long value = Long.parseLong(number);
			if (value>=Integer.MIN_VALUE && value <=Integer.MAX_VALUE){
				return value.intValue();
			} else {
				return value;
			}
		}
	}
	
	/**
	 * Moves to the next character. Also updates the offset.
	 * @return The current character after the move.
	 * @throws IOException If an IO exception occurs.
	 */
	private char next() throws IOException {
		offset ++;
		return current = (char)read();
	}
	
	/**
	 * @param start The start character.
	 * @return The end character.
	 */
	private Boolean readBoolean() throws IOException {
		skipWhiteSpace();
		if (current == 't'){
			findChars(new StringMatcher("true"));
			return true;
		} else if (current == 'f'){
			findChars(new StringMatcher("false"));
			return false;
		} else {
			throw new JSONParseException();
		}
	}
	
	/**
	 * @param start The start character.
	 * @return The end character.
	 */
	private Object readNull() throws IOException {
		findChars(new StringMatcher("null"));
		return null;
	}
	
	/**
	 * This will read from the stream, as long as the current character represents whitespace.
	 * @param start The character to start with (look ahead).
	 * @return The first non-whitespace character found, or -1 if at the end of the stream.
	 * @throws IOException If an IO exception occurs.
	 */
	private char skipWhiteSpace() throws IOException {
		return findChar(new CharFinder(){
			public boolean matches(char character) {
				return !isWhiteSpace(character);
			}
		});
	}
	
	/**
	 * This method will read up to the point where the found character matches the specified finder.
	 * @param start The start character.
	 * @param finder The character finder.
	 * @return The character found. -1 if the stream is at an end.
	 */
	private char findChar(CharFinder finder) throws IOException {
		while(!finder.matches(current) || isEndOfStream(current)){
			current = next();
		}
		return current;
	}
	
	/**
	 * Method for reading a set of characters from the input, as long as the delimiter does not match.
	 * @param delimiter The char finder delimiter.
	 * @return The read string.
	 * @throws IOException If an IO exception occurs.
	 */
	private String findChars(CharFinder delimiter) throws IOException {
		StringBuilder stringBuilder = new StringBuilder();
		while (!delimiter.matches(current) || isEndOfStream(current)){
			stringBuilder.append(current);
			current = next();
		}
		return stringBuilder.toString();
	}
	
	/**
	 * Reads a string while un-escaping characters.
	 * @param delimiter The delimiter to use.
	 * @return The unescaped string.
	 * @throws IOException If an IO exception occurs.
	 */
	private String unescapeChars(CharFinder delimiter) throws IOException {
		StringBuilder stringBuilder = new StringBuilder();
		char last=(char)-1;
		while (!delimiter.matches(last) || isEndOfStream(current)){			
			if (last != (char)-1 && last != '\\'){ // No escape sequence!
				stringBuilder.append(last);
			} else if (last == '\\'){ // Escape sequence!
				switch (current){
					case 'b':
						current='\b';
						break;
					case 'f':
						current='\f';
						break;
					case 'n':
						current='\n';
						break;
					case 'r':
						current='\r';
						break;
					case 't':
						current ='\t';
						break;					
					case 'u':
						// Read unicode!
						char[] unicode = new char[]{4};
						for (int i = 0; i < unicode.length;i++){
							unicode[i] = next();
						}
						String string = String.valueOf(unicode);
						current = (char)Integer.parseInt(string, 16);
						break;
				}				
			}
			last = current;
			current = next();
		}
		return stringBuilder.toString();
	}
	
	private interface CharFinder {
		/**
		 * Checks if a character matches the character to find.
		 * @param current The current character.
		 * @return true if match.
		 */
		boolean matches(char current);
	}
	
	@SuppressWarnings("unused")
	private class CharMatcher implements CharFinder {
		private char charToFind;
		
		/**
		 * Constructs a new char matcher.
		 * @param charToFind The character to find.
		 */
		public CharMatcher(char charToFind) {
			this.charToFind = charToFind;
		}
		
		/**
		 * {@inheritDoc}
		 */
		public boolean matches(char current) {
			return (current == charToFind); 
		}
	}
	
	private class StringMatcher implements CharFinder {
		private String stringToFind;
		int position = 0;
		
		/**
		 * Constructs a new char matcher.
		 * @param charToFind The character to find.
		 */
		public StringMatcher(String stringToFind) {
			this.stringToFind = stringToFind;			
		}
		
		/**
		 * {@inheritDoc}
		 */
		public boolean matches(char current) {			
			if (position == stringToFind.length()){
				return true;
			} else {
				if (stringToFind.charAt(position) != current){
					mismatch(stringToFind, position, current);
				}
			}
			position ++;
			return false;
		}
		
		public void mismatch(String stringToFind, int position, char current){
			StringBuilder builder = new StringBuilder("Expected ").append(stringToFind.charAt(position)).append(" found ").append(current).append('!');
			throw new RuntimeException(builder.toString());
		}
	}
	
	private static boolean isEndOfStream(char character){
		return character == -1;
	}
	
	private static boolean isWhiteSpace(char character){
		return character == ' ' || character == '\t' || character == '\r' || character == '\n';
	}
	
	private static boolean isStartObject(char character){
		return character == '{';
	}
	
	private static boolean isObjectEnd(char character){
		return character == '}';
	}
	
	private static boolean isArrayStart(char character){
		return character == '[';
	}
	
	private static boolean isArrayEnd(char character){
		return character == ']';
	}
	
	private static boolean isStringStart(char character){
		return character == '"';
	}
	
	private static boolean isStringEnd(char character){
		return isStringStart(character);
	}
	
	private static boolean isBooleanStart(char character){
		return character == 'f' || character == 't';
	}
	
	private static boolean isNullStart(char character){
		return character == 'n';
	}
	
	private static boolean isNumberStart(char character){
		return character >= '0' && character <='9';
	}
	
	/**
	 * Exception thrown when parsing finds an error.
	 * @author Daan Kets
	 *
	 */
	class JSONParseException extends RuntimeException {
		private static final long serialVersionUID = -6633848303396277196L;
		int position;
		
		public JSONParseException(int position) {
			this.position = position;
		}
		
		public JSONParseException() {
			this(JSONReader.this.offset);
		}
		
		@Override
		public String getMessage() {
			return "JSON Parse exception at position " + position;
		}
	}

}
